view.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. 'use client';
  2. import './style.scss';
  3. import Link from 'next/link';
  4. import { useRouter } from 'next/navigation';
  5. import { useState, useCallback, useRef, FormEvent } from 'react';
  6. import Loading from '@/app/component/Loading';
  7. import { BoardLayout, PostConst } from '@/constants/forum';
  8. import { fetchApi } from '@/lib/utils/client';
  9. import { checkPermission, isBoardAdmin } from '@/lib/utils/permission';
  10. import useAuth from '@/hooks/useAuth';
  11. import useErrorAlert from '@/hooks/useErrorAlert';
  12. import { BoardResponse } from '@/types/response/forum/board';
  13. import { BoardListResponse } from '@/types/response/forum/board';
  14. import { PostResponse } from '@/types/response/forum/post';
  15. import Editor, { Handle } from '../../_component/Editor';
  16. import HeaderContent from '../../_component/HeaderContent';
  17. import FooterContent from '../../_component/FooterContent';
  18. import PostTagInput from '../../_component/PostTagInput';
  19. type Props = {
  20. _boardList: BoardListResponse,
  21. _board: BoardResponse,
  22. _post: PostResponse
  23. };
  24. export default function View({ _boardList, _board, _post }: Props)
  25. {
  26. const router = useRouter();
  27. const { member } = useAuth();
  28. const { setError } = useErrorAlert();
  29. const [loading, setLoading] = useState<boolean>(false);
  30. const [isChanged, setIsChanged] = useState<boolean>(false);
  31. const [board, setBoard] = useState<BoardResponse|null>(_board);
  32. const [boardCode, setBoardCode] = useState<string>(_post.boardCode);
  33. const [boardPrefixID, setBoardPrefixID] = useState<string>(_post.boardPrefixID?.toString() ?? '');
  34. const [subject, setSubject] = useState<string>(_post.subject);
  35. const [content, setContent] = useState<string>(_post.content);
  36. const [isSecret, setIsSecret] = useState<boolean>(_post.isSecret);
  37. const [isNotice, setIsNotice] = useState<boolean>(_post.isNotice);
  38. const [isSpeaker, setIsSpeaker] = useState<boolean>(_post.isSpeaker);
  39. const [tags, setTags] = useState<string[]>(_post.tagList.map((tag) => tag.slug));
  40. const editorRef = useRef<Handle>(null);
  41. const boardCodeRef = useRef<HTMLSelectElement>(null);
  42. const boardPrefixIDRef = useRef<HTMLSelectElement>(null);
  43. const subjectRef = useRef<HTMLInputElement>(null);
  44. const contentRef = useRef<HTMLTextAreaElement>(null);
  45. const redirectUrl = `/post/${_post.id}`;
  46. // 게시글 초기화 (DB 데이터로 복원)
  47. const resetForm = () => {
  48. setIsChanged(false);
  49. setBoardPrefixID(_post.boardPrefixID?.toString() ?? '');
  50. setSubject(_post.subject);
  51. setIsSecret(_post.isSecret);
  52. setIsNotice(_post.isNotice);
  53. setIsSpeaker(_post.isSpeaker);
  54. setTags(_post.tagList.map((tag) => tag.slug));
  55. const originalContent = _post.content;
  56. setContent(originalContent);
  57. // Editor 초기화
  58. if (editorRef.current?.editorInstance) {
  59. editorRef.current.editorInstance.setData(originalContent);
  60. }
  61. };
  62. // 게시판 선택 시
  63. const handleBoardChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
  64. const code = e.target.value;
  65. if (!code) {
  66. return;
  67. }
  68. if (isChanged) {
  69. if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
  70. return
  71. }
  72. }
  73. setLoading(true);
  74. fetchApi<BoardResponse>('/api/forum/boards/' + code).then((res) => {
  75. if (res.success) {
  76. setBoardCode(code);
  77. setBoard(res.data);
  78. setIsChanged(false);
  79. resetForm();
  80. } else {
  81. throw new Error('게시판을 조회할 수 없습니다.');
  82. }
  83. }).catch((err) => {
  84. setError(err.message);
  85. }).finally(() => {
  86. setLoading(false);
  87. });
  88. }, [isChanged]);
  89. // 제목, 내용, 말머리 변경 시
  90. const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
  91. const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
  92. const checked = (e.target as HTMLInputElement).checked;
  93. switch (name) {
  94. case 'boardPrefixID':
  95. setBoardPrefixID(value);
  96. break;
  97. case 'isSecret':
  98. setIsSecret(checked);
  99. break;
  100. case 'isNotice':
  101. setIsNotice(checked);
  102. setIsSpeaker(false);
  103. break;
  104. case 'isSpeaker':
  105. setIsSpeaker(checked);
  106. setIsNotice(false);
  107. break;
  108. case 'subject':
  109. setSubject(value);
  110. break;
  111. case 'content':
  112. setContent(value);
  113. break;
  114. }
  115. setIsChanged(true);
  116. }, []);
  117. // CKEditor에서 내용 변경 시
  118. const handleEditorChange = useCallback((data: string) => {
  119. setContent(data);
  120. setIsChanged(true);
  121. }, []);
  122. const validate = useCallback(() => {
  123. if (!boardCode || !board) {
  124. boardCodeRef.current?.focus();
  125. throw new Error('게시판을 선택해주세요.');
  126. }
  127. if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefixID) {
  128. boardPrefixIDRef.current?.focus();
  129. throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
  130. }
  131. if (!subject) {
  132. subjectRef.current?.focus();
  133. throw new Error('제목을 입력해주세요.');
  134. } else if (subject.length > PostConst.MaxAllowedSubjectLength) {
  135. subjectRef.current?.focus();
  136. throw new Error(`제목은 ${PostConst.MaxAllowedSubjectLength}자 이내로 작성해주세요.`);
  137. }
  138. if (!content) {
  139. if (board.boardMeta.write.allowEditor) {
  140. editorRef.current?.editorInstance?.editing.view.focus();
  141. } else {
  142. contentRef.current?.focus();
  143. }
  144. throw new Error('내용을 입력해주세요.');
  145. } else if (!board.boardMeta.write.allowEditor) {
  146. // 기본 textarea 사용 시 글자 수 검사
  147. if (content.length > PostConst.MaxAllowedContentLength) {
  148. contentRef.current?.focus();
  149. throw new Error(`내용은 ${PostConst.MaxAllowedContentLength}자 이내로 작성해주세요.`);
  150. }
  151. }
  152. if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
  153. throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
  154. }
  155. }, [boardCode, board, boardPrefixID, subject, content, tags]);
  156. // 게시글 수정 처리
  157. const handleSubmit = useCallback(async (e: FormEvent) => {
  158. e.preventDefault();
  159. try {
  160. validate();
  161. setLoading(true);
  162. if (!board) {
  163. throw new Error('게시판을 선택해 주세요.');
  164. }
  165. const formData = new FormData();
  166. formData.append('postID', _post.id.toString());
  167. formData.append('boardID', board.id.toString());
  168. formData.append('boardCode', boardCode);
  169. formData.append('boardPrefixID', boardPrefixID);
  170. formData.append('isSecret', isSecret.toString());
  171. formData.append('isNotice', isNotice.toString());
  172. formData.append('isSpeaker', isSpeaker.toString());
  173. formData.append('subject', subject);
  174. if (content) {
  175. const doc = new DOMParser().parseFromString(content, 'text/html');
  176. doc.querySelectorAll('img[src]').forEach(img => {
  177. const src = img.getAttribute('src');
  178. if (src && src.startsWith('data:image/')) {
  179. img.setAttribute('src', 'data:image/');
  180. }
  181. });
  182. formData.append('content', doc.body.innerHTML);
  183. }
  184. // 태그
  185. if (board.boardMeta.write.allowTag) {
  186. tags.forEach(tag => formData.append('tags', tag));
  187. }
  188. // 파일 업로드 권한 체크
  189. const canUploadFile = checkPermission(board.boardMeta, board.boardManager, member).canUploadFile;
  190. // 이미지 정보
  191. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
  192. const images = editorRef.current?.getImageStore() || [];
  193. if (images.length > 0 && !canUploadFile) {
  194. throw new Error('이미지를 첨부할 수 있는 권한이 없습니다.');
  195. }
  196. images.forEach(i => {
  197. if (i.image?.size > 0 && i.name) {
  198. formData.append('images', i.image, i.name);
  199. }
  200. });
  201. }
  202. // 미디어 정보
  203. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
  204. const medias = editorRef.current?.getMediaStore() || [];
  205. if (medias.length > 0 && !canUploadFile) {
  206. throw new Error('영상을 첨부할 수 있는 권한이 없습니다.');
  207. }
  208. medias.forEach((m) => {
  209. if (m.url) {
  210. formData.append('medias', m.url);
  211. }
  212. });
  213. }
  214. // 첨부 파일
  215. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
  216. const files = editorRef.current?.getFileStore() || [];
  217. if (files.length > 0 && !canUploadFile) {
  218. throw new Error('파일을 첨부할 수 있는 권한이 없습니다.');
  219. }
  220. files.forEach(f => {
  221. if (f?.size > 0 && f.name) {
  222. formData.append('files', f.file, f.name);
  223. }
  224. });
  225. }
  226. const res = await fetchApi('/api/forum/posts/' + _post.id, {
  227. method: 'PUT',
  228. body: formData
  229. });
  230. if (res.success) {
  231. resetForm();
  232. router.push(redirectUrl);
  233. }
  234. } catch (err) {
  235. if (err instanceof Error) {
  236. setError(err.message);
  237. }
  238. }
  239. }, [boardCode, board, boardPrefixID, subject, content, isSecret, isNotice, isSpeaker, tags]);
  240. return (
  241. <form id='postEdit' onSubmit={handleSubmit}>
  242. {loading && <Loading />}
  243. <fieldset>
  244. <legend><h1>{board?.name} 글 수정</h1></legend>
  245. {/* 상단 안내 */}
  246. {<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
  247. {/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
  248. <section>
  249. {/* 게시판 선택 */}
  250. <article>
  251. <select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
  252. {_boardList.list.map((item) => (
  253. <option key={item.code} value={item.code}>{item.name}</option>
  254. ))}
  255. </select>
  256. </article>
  257. {/* 말머리 */}
  258. {board?.boardMeta.write.allowPrefix && (
  259. <article>
  260. <select name='boardPrefixID' ref={boardPrefixIDRef} value={boardPrefixID} onChange={handleChange} title='말머리 선택'>
  261. <option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
  262. {board.boardPrefix.map((row) => (
  263. <option key={row.id} value={row.id}>{row.name}</option>
  264. ))}
  265. </select>
  266. </article>
  267. )}
  268. <article>
  269. {/* 비밀글 */}
  270. {board?.boardMeta.write.allowSecret && (
  271. <>
  272. <input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
  273. <label htmlFor='isSecret'>비밀글</label>
  274. </>
  275. )}
  276. {isBoardAdmin(board?.boardManager ?? [], member) && (
  277. <>
  278. {/* 해당 게시판 공지 */}
  279. {!board?.boardMeta.list.exceptNotice && (
  280. <>
  281. <input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
  282. <label htmlFor='isNotice'>공지</label>
  283. </>
  284. )}
  285. {/* 게시판 전체 공지 */}
  286. {!board?.boardMeta.list.exceptSpeaker && (
  287. <>
  288. <input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
  289. <label htmlFor='isSpeaker'>전체 공지</label>
  290. </>
  291. )}
  292. </>
  293. )}
  294. </article>
  295. </section>
  296. {/* 제목 */}
  297. <section>
  298. <input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.MaxAllowedSubjectLength} />
  299. </section>
  300. {/* 내용 */}
  301. <section>
  302. {board?.boardMeta.write.allowEditor ?
  303. (
  304. <Editor ref={editorRef} key={boardCode} editorKey={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
  305. ) : (
  306. <textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
  307. )}
  308. </section>
  309. {/* 태그 */}
  310. {board?.boardMeta.write.allowTag && (
  311. <section id='postTag'>
  312. <PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
  313. </section>
  314. )}
  315. {/* 하단 안내 */}
  316. {<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
  317. <div>
  318. <br/>
  319. </div>
  320. <section>
  321. <button type='submit' className='btn btn-submit' disabled={loading}>
  322. { loading ? '수정 중…' : '확인' }
  323. </button>
  324. <Link href={redirectUrl} className='btn btn-default'>취소</Link>
  325. </section>
  326. </fieldset>
  327. </form>
  328. );
  329. }